/*
 * IrcConnection.java
 * 
 * This file is part of the Sorcix Java IRC Library (sIRC).
 * 
 * Copyright (C) 2008-2009 Vic Demuzere http://sorcix.com
 * 
 * Permission is hereby granted, free of charge, to any person
 * obtaining a copy of this software and associated documentation
 * files (the "Software"), to deal in the Software without
 * restriction, including without limitation the rights to use, copy,
 * modify, merge, publish, distribute, sublicense, and/or sell copies
 * of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * 
 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
 * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
 * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */
package com.sorcix.sirc;

import java.io.IOException;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Vector;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;

/**
 * Main IRC Connection class in sIRC.
 * <p>
 * sIRC acts as a layer between an IRC server and java applications.
 * It provides an event-driven architecture to handle common IRC
 * events.
 * </p>
 * 
 * @author Sorcix
 */
public final class IrcConnection {
	
	/**
	 * Listens to server events for internal use.
	 */
	private final class InternalListener implements ServerListener {
		
		@Override
		public void onConnect(final IrcConnection irc) {
		// not needed.
		}
		
		@Override
		public void onDisconnect(final IrcConnection irc) {
		// not needed.
		}
		
		@Override
		public void onInvite(final IrcConnection irc, final User sender, final User user, final Channel channel) {
		// not needed.
		}
		
		@Override
		public void onJoin(final IrcConnection irc, final Channel channel, final User user) {
			if (user.isUs()) {
				IrcConnection.this.channels.put(channel.getName(), channel);
			} else {
				IrcConnection.this.channels.get(channel.getName()).addUser(user);
			}
		}
		
		@Override
		public void onKick(final IrcConnection irc, final Channel channel, final User sender, final User user) {
			if (user.isUs()) {
				IrcConnection.this.channels.remove(channel.getName());
			} else {
				IrcConnection.this.channels.get(channel.getName()).removeUser(user);
			}
		}
		
		@Override
		public void onMode(final IrcConnection irc, final Channel channel, final User sender, final String mode) {
		// not needed.
		}
		
		@Override
		public void onNick(final IrcConnection irc, final User oldUser, final User newUser) {
			if (!oldUser.isUs()) {
				for (final Iterator<Channel> it = IrcConnection.this.getChannels(); it.hasNext();) {
					it.next().renameUser(oldUser, newUser);
				}
			} else {
				IrcConnection.this.client.setNick(newUser.getNick());
			}
		}
		
		@Override
		public void onPart(final IrcConnection irc, final Channel channel, final User user) {
			if (user.isUs()) {
				IrcConnection.this.channels.remove(channel.getName());
				IrcConnection.this.garbageCollection();
			} else {
				IrcConnection.this.channels.get(channel.getName()).removeUser(user);
			}
		}
		
		@Override
		public void onQuit(final IrcConnection irc, final User user) {
			if (!user.isUs()) {
				for (final Iterator<Channel> it = IrcConnection.this.getChannels(); it.hasNext();) {
					it.next().removeUser(user);
				}
			} else {
				IrcConnection.this.channels.clear();
			}
		}
		
		@Override
		public void onTopic(final IrcConnection irc, final Channel channel, final User sender, final String topic) {
			IrcConnection.this.channels.get(channel.getName()).setTopic(topic);
		}
	}
	
	/** The sIRC about string, used in CTCP */
	public static final String ABOUT = "Sorcix IRC Library (sIRC) v" + IrcConnection.VERSION;
	/** Debug: Show raw messages */
	protected static final boolean DEBUG_MSG = false;
	/** Whether this library should call garbage collection. */
	protected static final boolean GARBAGE_COLLECTION = true;
	/** sIRC Library version. */
	public static final String VERSION = "0.9.5";
	/** Advanced listener. */
	private AdvancedListener advancedListener = null;
	/** Channels we're in. */
	private final Map<String, Channel> channels;
	/** Client {@link User}. */
	private User client;
	/** Connection state. */
	private boolean connected = false;
	/** Connection InputStream thread. */
	private IrcInput in = null;
	/** The internal server listener. */
	private final ServerListener internalListener;
	/** Outgoing message delay. (Flood control) */
	private int messageDelay = 100;
	/** Message listeners. */
	private final List<MessageListener> messageListeners;
	/** Mode listeners. */
	private final List<ModeListener> modeListeners;
	/** Connection OutputStream thread. */
	protected IrcOutput out = null;
	/** Server Address. */
	private String serverAddress;
	/** Server listeners. */
	private final List<ServerListener> serverListeners;
	/** Server password (or null). */
	private String serverPassword;
	/** Server port. */
	private int serverPort = IrcConstants.DEFAULT_PORT;
	/** Services. */
	private final List<SIRCService> services;
	/** Connection socket. */
	private Socket socket = null;
	/** Whether to use SSL or not. */
	private boolean usingSSL = false;
	/** Custom version string. */
	private String version = null;
	
	/**
	 * Creates a new IrcConnection object.
	 */
	public IrcConnection() {
		this(null, 0, null);
	}
	
	/**
	 * Creates a new IrcConnection object.
	 * 
	 * @param server Server address.
	 */
	public IrcConnection(final String server) {
		this(server, IrcConstants.DEFAULT_PORT, null);
	}
	
	/**
	 * Creates a new IrcConnection object.
	 * 
	 * @param server Server address.
	 * @param port Port number to connect to.
	 */
	public IrcConnection(final String server, final int port) {
		this(server, port, null);
	}
	
	/**
	 * Creates a new IrcConnection object.
	 * 
	 * @param server Server address.
	 * @param port Port number to connect to
	 * @param password The password to use.
	 */
	public IrcConnection(final String server, final int port, final String password) {
		this.setServerAddress(server);
		this.setServerPort(port);
		this.setServerPassword(password);
		this.serverListeners = new Vector<ServerListener>(4);
		this.messageListeners = new Vector<MessageListener>(4);
		this.modeListeners = new Vector<ModeListener>(2);
		this.services = new Vector<SIRCService>(0);
		this.channels = new HashMap<String, Channel>(0);
		this.internalListener = this.new InternalListener();
		this.addServerListener(this.internalListener);
	}
	
	/**
	 * Creates a new IrcConnection object.
	 * 
	 * @param server Server address.
	 * @param password The password to use.
	 */
	public IrcConnection(final String server, final String password) {
		this(server, IrcConstants.DEFAULT_PORT, password);
	}
	
	/**
	 * Adds a message listener to this IrcConnection.
	 * 
	 * @param listener The message listener to add.
	 */
	public void addMessageListener(final MessageListener listener) {
		if ((listener != null) && !this.messageListeners.contains(listener)) {
			this.messageListeners.add(listener);
		}
	}
	
	/**
	 * Adds a mode listener to this IrcConnection. Note that adding
	 * mode listeners will cause sIRC to check every incoming mode
	 * change for supported modes. Modes can also be read by using
	 * {@link ServerListener#onMode(IrcConnection, Channel, User, String)}
	 * which can be a lot faster for reading modes.
	 * 
	 * @param listener The mode listener to add.
	 */
	public void addModeListener(final ModeListener listener) {
		if ((listener != null) && !this.modeListeners.contains(listener)) {
			this.modeListeners.add(listener);
		}
	}
	
	/**
	 * Adds a server listener to this IrcConnection.
	 * 
	 * @param listener The server listener to add.
	 */
	public void addServerListener(final ServerListener listener) {
		if ((listener != null) && !this.serverListeners.contains(listener)) {
			this.serverListeners.add(listener);
		}
	}
	
	/**
	 * Add and load a service to this IrcConnection.
	 * 
	 * @param service The service to add.
	 */
	public void addService(final SIRCService service) {
		if ((service != null) && !this.services.contains(service)) {
			this.services.add(service);
			service.load(this);
		}
	}
	
	/**
	 * Closes all streams.
	 */
	private void close() {
		try {
			// close input stream
			this.in.close();
			// close output stream
			this.out.close();
			// close socket
			if (this.socket.isConnected()) {
				this.socket.close();
			}
		} catch (final Exception ex) {
			// ignore
		}
	}
	
	/**
	 * Connect to the IRC server. You must set the server details and
	 * nickname before calling this method!
	 * 
	 * @throws UnknownHostException When the domain name is invalid.
	 * @throws IOException When anything went wrong while connecting.
	 * @throws NickNameException If the given nickname is already in
	 *             use or invalid.
	 * @see #setServer(String, int)
	 * @see #setNick(String)
	 * @since 1.0.0
	 */
	public void connect() throws UnknownHostException, IOException, NickNameException {
		// don't even try if nickname is empty
		if ((this.client == null)) {
			throw new NickNameException("Nickname is empty or null!");
		}
		// connect socket
		SocketFactory sfact;
		if (this.usingSSL) {
			sfact = SSLSocketFactory.getDefault();
		} else {
			sfact = SocketFactory.getDefault();
		}
		this.socket = sfact.createSocket(this.serverAddress, this.serverPort);
		// open streams
		this.out = new IrcOutput(this, this.socket.getOutputStream());
		this.in = new IrcInput(this, this.socket.getInputStream());
		// send password if given
		if (this.serverPassword != null) {
			this.out.sendNowEx("PASS " + this.serverPassword);
		}
		// register
		this.out.sendNowEx("NICK " + this.client.getNick());
		this.out.sendNowEx("USER " + this.client.getUserName() + " Sorcix.com * :" + this.client.getNick());
		// wait for reply
		String line = null;
		while ((line = this.in.getReader().readLine()) != null) {
			if (IrcConnection.DEBUG_MSG) {
				System.out.println("<<< " + line);
			}
			if (line.indexOf("004") >= 0) {
				// login is OK.
				break;
			} else if ((line.indexOf("433") >= 0) || (line.indexOf("432") >= 0)) {
				// wrong nickname
				throw new NickNameException("Nickname already in use or not allowed!");
			} else if (line.startsWith("PING ")) {
				this.out.sendNowEx("PONG " + line.substring(5));
			}
		}
		// start listening
		this.in.start();
		this.out.start();
		// we are connected
		this.setConnected(true);
		// send events
		for (final Iterator<ServerListener> it = this.getServerListeners(); it.hasNext();) {
			it.next().onConnect(this);
		}
	}
	
	/**
	 * Connect to the IRC server with given nickname.
	 * 
	 * @param nick Nick to use.
	 * @throws UnknownHostException When the domain name is invalid.
	 * @throws IOException When anything went wrong while connecting.
	 * @throws NickNameException If the given nickname is already in
	 *             use or invalid.
	 * @deprecated This method has been replaced by {@link #connect()}
	 *             .
	 */
	@Deprecated
	public void connect(final String nick) throws UnknownHostException, IOException, NickNameException {
		this.setNick(nick);
		this.connect();
	}
	
	/**
	 * Creates a {@link Channel} object with given channel name. Note
	 * that this method does not actually create a channel on the IRC
	 * server, it just creates a {@link Channel} object linked to this
	 * {@code IrcConnection}.
	 * 
	 * @return A {@code Channel} object representing given channel.
	 * @param name The channel name, starting with #.
	 */
	public Channel createChannel(String name) {
		if (IrcConstants.CHANNEL_PREFIX.indexOf(name.charAt(0)) < 0) {
			name = "#" + name;
		}
		if (this.channels.containsKey(name)) {
			return this.channels.get(name);
		} else {
			return new Channel(name, this);
		}
	}
	
	/**
	 * Creates a {@link User} object with given nickname.
	 * 
	 * @param nick The nickname.
	 * @return A {@code User} object representing given user.
	 * @see User#User(String, IrcConnection)
	 */
	public User createUser(final String nick) {
		return new User(nick, this);
	}
	
	/**
	 * Disconnects from the server. In the case a connection to the
	 * server is alive, this method will send the QUIT command and
	 * wait for the server to disconnect us.
	 */
	public void disconnect() {
		this.disconnect(null);
	}
	
	/**
	 * Disconnects from the server. In the case a connection to the
	 * server is alive, this method will send the QUIT command and
	 * wait for the server to disconnect us.
	 * 
	 * @param message The QUIT message to use.
	 */
	public void disconnect(String message) {
		if (this.isConnected()) {
			if ((message == null) || message.trim().isEmpty()) {
				message = IrcConnection.ABOUT;
			}
			this.out.sendNow("QUIT :" + message);
		} else {
			this.close();
		}
	}
	
	protected void garbageCollection() {
		if (IrcConnection.GARBAGE_COLLECTION) {
			System.gc();
		}
	}
	
	/**
	 * Gives the advanced listener used by this {@code IrcConnection}.
	 * 
	 * @return The advanced listener, or null.
	 */
	protected AdvancedListener getAdvancedListener() {
		return this.advancedListener;
	}
	
	/**
	 * Gives all channels we're currently in.
	 * 
	 * @return All channels we're currently in.
	 */
	public Iterator<Channel> getChannels() {
		return this.channels.values().iterator();
	}
	
	/**
	 * Returns the client used by this {@code IrcConnection}.
	 * 
	 * @return User representing this client.
	 */
	public User getClient() {
		return this.client;
	}
	
	/**
	 * Returns an instance of the internal listener used by
	 * IrcConnection.
	 * 
	 * @return The internal {@link ServerListener}.
	 */
	protected ServerListener getInternalListener() {
		return this.internalListener;
	}
	
	/**
	 * Returns the outgoing message delay in milliseconds.
	 * 
	 * @return Outgoing message delay in milliseconds.
	 */
	public int getMessageDelay() {
		return this.messageDelay;
	}
	
	/**
	 * Returns all {@link MessageListener}s registered with this
	 * IrcConnection.
	 * 
	 * @return All {@code MessageListeners}.
	 */
	protected Iterator<MessageListener> getMessageListeners() {
		return this.messageListeners.iterator();
	}
	
	/**
	 * Returns all {@link ModeListener}s registered with this
	 * IrcConnection.
	 * 
	 * @return All {@code ModeListeners}.
	 */
	protected Iterator<ModeListener> getModeListeners() {
		return this.modeListeners.iterator();
	}
	
	/**
	 * Returns the output thread used for sending messages through
	 * this {@code IrcConnection}.
	 * 
	 * @return The {@code IrcOutput} used to send messages.
	 */
	protected IrcOutput getOutput() {
		return this.out;
	}
	
	/**
	 * Gives the server address this {@code IrcConnection} is using to
	 * connect.
	 * 
	 * @return Server address.
	 * @since 1.0.0
	 */
	public String getServerAddress() {
		return this.serverAddress;
	}
	
	/**
	 * Returns all {@link ServerListener}s registered with this
	 * IrcConnection.
	 * 
	 * @return All {@code ServerListeners}.
	 */
	protected Iterator<ServerListener> getServerListeners() {
		return this.serverListeners.iterator();
	}
	
	/**
	 * Gives the port number this {@code IrcConnection} is using to
	 * connect.
	 * 
	 * @return Port number
	 * @since 1.0.0
	 */
	public int getServerPort() {
		return this.serverPort;
	}
	
	/**
	 * Returns all services running on this IrcConnection.
	 * 
	 * @return All running services.
	 */
	private Iterator<SIRCService> getServices() {
		return this.services.iterator();
	}
	
	/**
	 * Gives the version string used.
	 * 
	 * @return The version string.
	 * @since 0.9.4
	 */
	protected String getVersion() {
		if (this.version != null) {
			return this.version;
		}
		return IrcConnection.ABOUT;
	}
	
	/**
	 * Checks whether mode listeners are registered.
	 * 
	 * @return True if mode listeners are registered, false otherwise.
	 */
	protected boolean hasModeListeners() {
		return this.modeListeners.size() > 0;
	}
	
	/**
	 * Checks whether the client is still connected.
	 * 
	 * @return True if the client is connected, false otherwise.
	 */
	public boolean isConnected() {
		return this.connected;
	}
	
	/**
	 * Checks if given {@link User} object represents us.
	 * 
	 * @param user {@code User} to check
	 * @return True if given {@code User} represents us, false
	 *         otherwise.
	 */
	public boolean isUs(final User user) {
		return user.equals(this.client);
	}
	
	/**
	 * Checks whether this connection is using SSL.
	 * 
	 * @return True if this connection is using SSL, false otherwise.
	 */
	public boolean isUsingSSL() {
		return this.usingSSL;
	}
	
	/**
	 * Unload and remove all services from this IrcConnection.
	 */
	public void removeAllServices() {
		if (this.services.size() > 0) {
			for (final Iterator<SIRCService> it = this.getServices(); it.hasNext();) {
				this.removeService(it.next());
			}
		}
	}
	
	/**
	 * Removes a message listener from this IrcConnection.
	 * 
	 * @param listener The message listener to remove.
	 */
	public void removeMessageListener(final MessageListener listener) {
		if ((listener != null) && this.messageListeners.contains(listener)) {
			this.messageListeners.remove(listener);
		}
	}
	
	/**
	 * Removes a mode listener from this IrcConnection.
	 * 
	 * @param listener The mode listener to remove.
	 */
	public void removeModeListener(final ModeListener listener) {
		if ((listener != null) && this.modeListeners.contains(listener)) {
			this.modeListeners.remove(listener);
		}
	}
	
	/**
	 * Removes a server listener from this IrcConnection.
	 * 
	 * @param listener The server listener to remove.
	 */
	public void removeServerListener(final ServerListener listener) {
		if ((listener != null) && this.serverListeners.contains(listener)) {
			this.serverListeners.remove(listener);
		}
	}
	
	/**
	 * Unload and remove a service from this IrcConnection.
	 * 
	 * @param service The service to remove.
	 */
	public void removeService(final SIRCService service) {
		if ((service != null) && !this.services.contains(service)) {
			service.unload(this);
			this.services.remove(service);
		}
	}
	
	/**
	 * Sets the advanced listener used in this {@code IrcConnection}.
	 * 
	 * @param listener The advanced listener to use, or {@code null}.
	 */
	public void setAdvancedListener(final AdvancedListener listener) {
		this.advancedListener = listener;
	}
	
	/**
	 * Changes the connection state of the client.
	 * 
	 * @param connected Whether we are still connected.
	 */
	protected void setConnected(final boolean connected) {
		this.connected = connected;
	}
	
	/**
	 * Sets the outgoing message delay in milliseconds. Note that
	 * sending a lot of messages in a short period of time might cause
	 * the server to disconnect you. The default is 1 message each
	 * 100ms.
	 * 
	 * @param messageDelay The outgoing message delay in milliseconds.
	 */
	public void setMessageDelay(final int messageDelay) {
		if (messageDelay < 0) {
			throw new IllegalArgumentException("Message Delay can't be negative!");
		}
		this.messageDelay = messageDelay;
	}
	
	/**
	 * Changes the nickname of this client. While connected, this
	 * method will attempt to change the nickname on the server.
	 * 
	 * @param nick New nickname.
	 */
	public void setNick(final String nick) {
		if (!this.isConnected()) {
			if (nick != null) {
				if (this.client == null) {
					this.client = new User(nick, "sIRC", null, this);
					return;
				}
				this.client.setNick(nick);
			}
		} else {
			this.out.sendNow("NICK " + nick);
		}
	}
	
	/**
	 * Sets the server details to use while connecting.
	 * 
	 * @param address The address of the server.
	 * @param port The port number to use.
	 * @since 1.0.0
	 */
	public void setServer(final String address, final int port) {
		this.setServerAddress(address);
		this.setServerPort(port);
	}
	
	/**
	 * Sets the server address to use while connecting.
	 * 
	 * @param address The address of the server.
	 * @since 1.0.0
	 */
	public void setServerAddress(final String address) {
		if (!this.isConnected() && (address != null)) {
			this.serverAddress = address;
		}
	}
	
	/**
	 * Sets the server password to use while connecting. If the server
	 * does not require a password, set this to {@code null}.
	 * 
	 * @param password The server password.
	 * @since 1.0.0
	 */
	private void setServerPassword(final String password) {
		this.serverPassword = password;
	}
	
	/**
	 * Sets the server address to use while connecting.
	 * 
	 * @param port The port number to use.
	 */
	public void setServerPort(final int port) {
		if (!this.isConnected() && (port > 0)) {
			this.serverPort = port;
		}
	}
	
	/**
	 * Sets whether this connection should use SSL to connect. Note
	 * that the connection will fail if the server has no valid
	 * certificate. This property can only be changed while sIRC is
	 * not connected to an IRC server.
	 * 
	 * @param usingSSL True to use SSL, false otherwise.
	 * @see #setServerPort(int)
	 */
	public void setUsingSSL(final boolean usingSSL) {
		if (!this.isConnected()) {
			this.usingSSL = usingSSL;
		}
	}
	
	/**
	 * Set the string returned on CTCP VERSION and FINGER commands.
	 * 
	 * @param version The string to return on CTCP VERSION and FINGER
	 *            commands, or {@code null} to use the default sIRC
	 *            version string.
	 * @since 0.9.4
	 */
	public void setVersion(final String version) {
		this.version = version;
	}
}
